package com.cordys.opensource.crom;

import com.cordys.cpc.bsf.busobject.BusObject;
import com.cordys.cpc.bsf.busobject.BusObjectUID;
import com.cordys.cpc.bsf.classinfo.RelationInfo_FK;
import com.cordys.opensource.crom.sql.Column;
import com.cordys.opensource.crom.sql.Query;
import com.cordys.opensource.crom.sql.Where.Operator;

/**
 * This class has framework logic to handle the interaction between domain objects the corresponding database table.
 * This pattern works as follows:
 * <br/>
 * <br/>
 * 
 * For a certain domain object, e.g. Solution, when an update/insert is done on it,
 * the corresponding database table is updated/inserted with the values from the domain object.
 * Updates on the Revision tables is done by the {@link com.cordys.opensource.crom.RevisionObjectEventListener}.
 * <br/>
 * <br/>
 * 
 * Instead of building this logic in each individual java class, the DomainObjectEventListener is a framework level class that listens
 * to the events inside the Java objects (such as Insert, Update, Delete), and takes the appropriate actions on the db tables.
 * 
 * @see com.cordys.opensource.crom.BusObjectWrapper
 * @see com.cordys.opensource.crom.BusObjectMetadata
 * @see com.cordys.opensource.crom.DomainObject
 * @see com.cordys.opensource.crom.RevisionObjectEventListener
 *
 */
class DomainObjectEventListener
{
	/**
	 * Holds the wrapper to the domain object
	 */
	private BusObjectWrapper domainObjectWrapper;
	/**
	 * Holds the metadata about the domain object
	 */
	private BusObjectMetadata metadata;
	/**
	 * Holds the domain object itself
	 */
	private BusObject domainObject;
	/**
	 * Holds the database record corresponding to the domain object.
	 */
	private BusObject dbObject;
	/**
	 * This listener will be invoked if the domain object has
	 *  the RevisionObject annotation.
	 */
	private RevisionObjectEventListener revisionObjectEventListener;
	
	/**
	 * Creates a new listener for the specified object
	 * @param domainObjectWrapper The wrapper of the domain object
	 * @param metadata The metadata about the domain object
	 */
	DomainObjectEventListener(BusObjectWrapper domainObjectWrapper, BusObjectMetadata metadata)
	{
		this.domainObjectWrapper = domainObjectWrapper;
		this.metadata = metadata;
		this.domainObject = domainObjectWrapper.getDomainObject();	
		if (BusObjectMetadata.isRevisionedObject(domainObject.getClass()))
		{
			revisionObjectEventListener = new RevisionObjectEventListener(domainObjectWrapper, metadata);
		}
	}
	
	/**
	 * Returns the db object for the domain object. If none is available a new one is 
	 * created and stored in the domain object wrapper. 
	 * The insert/update method is NOT invoked on the db object, since that has to be done in the update or insert method.
	 * @param domainObject
	 * @return The db object for the domain object.
	 */
	BusObject getDBObject()
	{
		if (dbObject!=null)
		{
			return dbObject;
		}
		
		dbObject = domainObjectWrapper.getPersistenceMapping().getDatabaseObject();
		if (dbObject==null)
		{
			if (domainObjectWrapper.isMasterData())
			{
				findMasterDataObject();
			}
			else
			{
				// TODO: check whether we can make this method
				//  contain more functionality. Currently, getting the db object does
				//  not give it always the right values. One use case that comes to mind:
				//  in case of an update, when e.g. Ticket.onBeforeUpdate() application code invokes getDBObject, 
				//  then old will not be filled, and dbObject only contains new values. So this may be an issue.
				//  Disadvantage is that it is a major change, so will be little hard to understand all impact.
//				boolean isExisting = this.domainObject.isPersistentExisting();
//				boolean isNew = this.domainObject.isPersistentNew();
//				boolean isPersistent = this.domainObject.isPersistent();
				try
				{
					dbObject = (BusObject) metadata.getDbClass().newInstance();
					BusObjectHelper.mapDBClassAttributes(dbObject, domainObject, false);
					dbObject.makeTransient();
					domainObjectWrapper.getPersistenceMapping().setDatabaseObject(dbObject);
				}
				catch (InstantiationException e)
				{
					throw new RuntimeException("Fatal error instantiating the dbClass "+metadata.getDbClass(),e);
				}
				catch (IllegalAccessException e)
				{
					throw new RuntimeException("Fatal error instantiating the dbClass "+metadata.getDbClass(),e);
				}
			}
		}
		return dbObject;
	}

	/**
	 * Returns a db mapping object for the domain object. This is only relevant
	 * if the domain object sits inside a parent object.
	 * @return
	 */
	BusObject getDBMappingObject()
	{
		BusObject domainParent = BusObjectWrapper.getDomainParent(domainObject);
		if (domainParent==null)
		{
			return null;
		}
		BusObject dbParent = BusObjectWrapper.getDatabaseObject(domainParent);
		return getDBMappingObject(dbParent);
	}
	
	/**
	 * This method has been split off getDBMappingObject(), because of the revisioning
	 * pattern, in which the dbParent can be either the "real" domain related table, or the revision related table.
	 * @param dbParent
	 * @return
	 */
	private BusObject getDBMappingObject(BusObject dbParent)
	{
		Class<? extends BusObject> mappingClass = metadata.getMappingClass();
		if (mappingClass==null)
		{
			// Could not find a relationship table.
			return null;
		}
		
		
		// Now create a new instance for the mapping table, and fill it with attributes and foreign key values.
		BusObject dbMappingObject = domainObjectWrapper.getPersistenceMapping().getMappingObject();
		if (dbMappingObject==null)
		{
			try
			{
				dbMappingObject = mappingClass.newInstance();
				dbMappingObject.makeTransient();
				BusObjectHelper.relateObjects(dbParent, dbMappingObject, BusObjectHelper.getForeignKeyRelation(dbParent.getClass(), dbMappingObject.getClass()));
				domainObjectWrapper.getPersistenceMapping().setMappingObject(dbMappingObject);
			}
			catch (InstantiationException e)
			{
				throw new RuntimeException("Cannot instantiate a new mapping object for inserting domain object "+domainObject, e);
			}
			catch (IllegalAccessException e)
			{
				throw new RuntimeException("Cannot instantiate a new mapping object for inserting domain object "+domainObject, e);
			}
		}
		return dbMappingObject;
	}
	
	void onBeforeInsert()
	{
		// First get the database object.
		dbObject = getDBObject();
		
		if (domainObjectWrapper.isMasterData())
		{
			if (dbObject==null)
			{
				// This is an error (except for cases where we should insert the masterdata, e.g. CommentTemplates being generated from rules)
				throw new RuntimeException("The specified masterdata cannot be found in the database table "+metadata.getDbClassInfo().getTableName()+". Specified masterdata: "+domainObject);
			}
		}
		else
		{
			BusObjectHelper.mapDBClassAttributes(dbObject, domainObject, false);
			dbObject.insert();
		}
		
		// Now make the association to the parent object, if available
		BusObject domainParent = domainObjectWrapper.getDomainParent();
		if (domainParent!=null)
		{
			associateParentObject(domainParent);
		}
		
		if (revisionObjectEventListener!=null)
		{
			if (domainObjectWrapper.isMasterData() && !dbObject.isPersistent())
			{
				// It is an already existing masterdata, so we should not insert a new revision for it!
				return;
			}
			revisionObjectEventListener.onBeforeInsert();
		}
	}
	
	void onBeforeUpdate()
	{
		BusObject original = domainObject.getOriginalObject();
		if (original==null)
		{
			// Is this an insert?? :) Cannot do anything with it, because basically there is not yet any update available.
			// Typically will happen e.g. UpdateTicket is done with old and new content both the exact same data.
			//  When reading the BusObject from the SOAP Request, WS-AppServer will first read <old> to initialize the
			//  object and subsequently the <new>. Upon encountering the first "changed" property, WS-AppServer will
			//  create an "original" object, and set the new property in the current object. If no changed property is
			//  encountered, there will not be an "original" object, but still the update() method is invoked.
			return;
		}
		if (BusObjectHelper.objectsAreEqual(this.domainObject, original))
		{
			// No change detected, let's not try to do anything.
			return;
		}

		BusObjectUID newUID = BusObjectHelper.getUID(domainObject);
		BusObjectUID oldUID = BusObjectHelper.getUID(original);
		
		if (newUID.equals(oldUID))
		{
			// Apparently we need to go into update logic.
			try
			{
				// TODO: in case of MasterData, then we may have to look up the masterdata object by value!?
				if (dbObject==null) dbObject = (BusObject) metadata.getDbClass().newInstance();
				domainObjectWrapper.getPersistenceMapping().setDatabaseObject(dbObject);
			}
			catch (InstantiationException e)
			{
				throw new RuntimeException("Fatal error instantiating the dbClass "+metadata.getDbClass(),e);
			}
			catch (IllegalAccessException e)
			{
				throw new RuntimeException("Fatal error instantiating the dbClass "+metadata.getDbClass(),e);
			}
			dbObject.makeTransient();
			BusObjectHelper.mapDBClassAttributes(dbObject, original, false);
			dbObject.setAsPersistent();
			BusObjectHelper.mapDBClassAttributes(dbObject, domainObject, false, false);
			dbObject.update();
			
			// Also update mapping table if any.
			BusObject domainParent = domainObjectWrapper.getDomainParent();
			if (domainParent!=null) 
			{
				updateMappingTable(BusObjectWrapper.getDatabaseObject(domainParent), dbObject);
			}
			
		}
		else // apparently we change the object relationship with parent, not the object itself.
		{
			dbObject = getDBObject();
			BusObject domainParent = domainObjectWrapper.getDomainParent();
			if (domainParent!=null) 
			{
				// change the association between the objects: remove the old one from the association, add the new one
				associateParentObject(domainParent);
				unassociateParentObject(domainParent);
			}
		}
		if (revisionObjectEventListener!=null) revisionObjectEventListener.onBeforeUpdate();
	}


	void onBeforeDelete()
	{
		// TODO: this should become also aligned with update: indicate whether
		//  cascading delete, etc.
		
		// First get the database object.
		dbObject = getDBObject();
		
		// Currently only MasterData objects will not be deleted. For the rest,
		//  it is cascading delete of everything passed in the SOAP request.
		// For deletion of MasterData: this should be done only if the masterdata is the root object
		if (!domainObjectWrapper.isMasterData())
		{
			BusObjectHelper.mapDBClassAttributes(dbObject, domainObject, false);
			dbObject.setAsPersistent();
			dbObject.delete();
			
			// Now also raise the events for the temporal pattern
			if (revisionObjectEventListener!=null)
			{
				// If this domain object implements the temporal pattern,
				//  it can never be deleted. So we remove it from the transaction.
				dbObject.makeTransient();
				// And now let the temporal pattern fill the expiry fields in the revision table
				revisionObjectEventListener.onBeforeDelete();
			}
		}
		
		// If there is a mapping table between this and the parent, then it needs to be cleared in all cases.
		clearMappingTable(dbObject);
	}
	
	/**
	 * Associates the records belonging to the domain object and it's domain parent.
	 * Can be done by filling foreign key attributes, or by filling mapping table. 
	 * @param domainParent
	 */
	private void associateParentObject(BusObject domainParent)
	{
		// Get the db object and the db revision object for the parent, and relate them.
		//  Try to relate these objects.
		BusObject dbParent = BusObjectWrapper.getDatabaseObject(domainParent);

		if (BusObjectMetadata.isRevisionedObject(domainParent.getClass()))
		{
			// In case of a Revisioned object, we may have to set the foreign key
			//  relationship on the plain dbParent and perhaps also on the revision of the parent.
			BusObject dbParentRevision = BusObjectWrapper.getDatabaseRevisionObject(domainParent);

			relateParentChild(dbParent, dbObject, domainParent);
			relateParentChild(dbParentRevision, dbObject, domainParent);
		}
		else
		{
			relateParentChild(dbParent, dbObject, domainParent);
		}
	}
	
	/**
	 * Relates the parent and child object to each other. Either this will
	 * be done via a Foreign Key relationship, or, if such a relation is not found,
	 * a search for a mapping table will be done, and if found, that will be filled.
	 * Parent can be either a "plain" or a "revisioned" record.
	 * @param dbParent
	 * @param dbObject
	 * @param domainParent
	 */
	private void relateParentChild(BusObject dbParent, BusObject dbObject, BusObject domainParent)
	{
		RelationInfo_FK fkRelation = metadata.getParentForeignKey();
		
		// If there is a foreign key relation, then we have to associate the db objects ...
		if (fkRelation!=null)
		{
			if (BusObjectHelper.getRelatedClass(fkRelation).equals(dbParent.getClass()))
			{
				// Make sure parent will also be modified and persisted.
				if (!dbParent.isPersistent())
				{
					dbParent.setAsPersistent();
				}
				BusObjectHelper.relateObjects(dbObject, dbParent, fkRelation);
				// Also map back the newly assigned attributes into the domain parent
				//  This is required because in many queries domain parent also holds the FK field
				//  and during the onAfterUpdate handler we overwrite again the values
				//  of the dbParent, effectively causing above code to have no effect.
				BusObjectHelper.mapDBClassAttributes(dbParent, domainParent, true);
			}
		}
		else // ... else there might be a mapping table to be filled... 
		{
			insertMappingTable(dbParent, dbObject);
		}
	}
	
	/**
	 * Removes the association between the domain object and it's parent.
	 * This is only relevant in case there is a relationship between parent and child through a mapping table.
	 * (If the relationship is through foreign key, then the key value is overwritten by the new object that will be associated).
	 * @param domainParent
	 */
	private void unassociateParentObject(BusObject domainParent)
	{
		// If there is a mapping class type of relationship between parent and child, then clean the mapping table with the proper value
		Class<? extends BusObject> mappingClass = metadata.getMappingClass();
		if (mappingClass!=null)
		{
			// Create a transient copy of the "old" database object
			//  This has to be "unassociated" from the mapping table.
			BusObject oldDBObject = null;
			try
			{
				oldDBObject = (BusObject) metadata.getDbClass().newInstance();
				BusObjectHelper.mapDBClassAttributes(oldDBObject, domainObject.getOriginalObject(), false);
				oldDBObject.makeTransient();
			}
			catch (InstantiationException e)
			{
				throw new RuntimeException("Fatal error instantiating the dbClass "+metadata.getDbClass(),e);
			}
			catch (IllegalAccessException e)
			{
				throw new RuntimeException("Fatal error instantiating the dbClass "+metadata.getDbClass(),e);
			}
			
			clearMappingTable(oldDBObject);
		}
	}

	/**
	 * Checks if there is a mapping table between the dbObject and it's parent.
	 * If so, it will insert a mapping record, and fill it with the proper values.
	 * @param dbParent
	 * @param dbObject
	 */
	private void insertMappingTable(BusObject dbParent, BusObject dbObject)
	{
		BusObject mappingObject = getDBMappingObject(dbParent);
		if (mappingObject==null)
		{
			return;
		}
		mappingObject.insert();
		// Also invoke mapAttributes because the mapping table may also store values of the child
		BusObjectHelper.mapDBClassAttributes(mappingObject, domainObject, false);
		BusObjectHelper.relateObjects(dbObject, mappingObject, BusObjectHelper.getForeignKeyRelation(dbObject.getClass(), mappingObject.getClass()));
	}
	
	/**
	 * Checks if there is a mapping table between the dbObject and it's parent.
	 * If so, it will update the mapping record, and fill it with the proper values.
	 * @param dbParent
	 * @param dbObject
	 */
	private void updateMappingTable(BusObject dbParent, BusObject dbObject)
	{
		BusObject mappingObject = getDBMappingObject(dbParent);
		if (mappingObject==null)
		{
			return;
		}
		
		// Also invoke mapAttributes because the mapping table may also store values of the child
		mappingObject.makeTransient();
		BusObjectHelper.relateObjects(dbObject, mappingObject, BusObjectHelper.getForeignKeyRelation(dbObject.getClass(), mappingObject.getClass()));

		BusObjectHelper.mapDBClassAttributes(mappingObject, domainObject.getOriginalObject(), false);
		mappingObject.setAsPersistent();
		BusObjectHelper.mapDBClassAttributes(mappingObject, domainObject, false, false);
		mappingObject.update();
	}

	/**
	 * Checks if there is a mapping table between the dbBusObject and 
	 * the parent object of the domain object.
	 * This method can be invoked both on the original db object as on the current, hence this
	 * is passed as parameter.
	 * @param dbBusObject
	 */
	private void clearMappingTable(BusObject dbBusObject)
	{
		// TODO: no implementation available for RevisionedObjects?
		
		// First check if the object has a parent. If not we're done.
		BusObject domainParent = domainObjectWrapper.getDomainParent();
		if (domainParent==null) 
		{
			return;
		}

		// Get the db object and the db revision object for the parent, and relate them.
		//  Try to relate these objects.
		BusObject dbParent = BusObjectWrapper.getDatabaseObject(domainParent);
		
		BusObject mappingObject = getDBMappingObject(dbParent);
		if (mappingObject==null)
		{
			return;
		}
		BusObjectHelper.relateObjects(dbBusObject, mappingObject, BusObjectHelper.getForeignKeyRelation(dbBusObject.getClass(), mappingObject.getClass()));
		mappingObject.setAsPersistent();

		mappingObject.delete();
	}
	
	void onAfterInsert()
	{
		// Again map attributes to take also include those attributes that have
		//  have been set during the domain object's onInsert method (i.e., the application logic)
		BusObjectHelper.mapDBClassAttributes(dbObject, domainObject, false);
		if (revisionObjectEventListener!=null)
		{
			if (domainObjectWrapper.isMasterData() && !dbObject.isPersistent())
			{
				// Should not insert a new revision (see also onBeforeInsert)
				return;
			}
			revisionObjectEventListener.onAfterInsert();
		}
	}
	
	void onAfterUpdate()
	{
		if (dbObject==null)
		{
			// This may happen in case the onBeforeUpdate returned
			//  before creating a dbObject.
			return;
		}
		// Again map attributes to take also include those attributes that have
		//  have been set during the domain object's onUpdate method (i.e., the application logic)
		BusObjectHelper.mapDBClassAttributes(dbObject, domainObject, false);
		if (revisionObjectEventListener!=null) revisionObjectEventListener.onAfterUpdate();
	}

	void onAfterDelete()
	{
		if (revisionObjectEventListener!=null)
		{
			// Only expire the revision if it is not a MasterData object.		
			if (!domainObjectWrapper.isMasterData())
			{
				revisionObjectEventListener.onAfterDelete();
			}
		}
	}

	void onBeforeCommit()
	{
		if (revisionObjectEventListener!=null) revisionObjectEventListener.onBeforeCommit();
	}

	void onBeforeAbort()
	{
		if (revisionObjectEventListener!=null) revisionObjectEventListener.onBeforeAbort();
	}

	/**
	 * After the commit to the transaction happened, we have to map the db attributes
	 * back into the domain object. This is required to e.g. return the db-generated
	 * Id fields back to the client.
	 */
	void onAfterCommit()
	{
		// make transient to prevent that the update listeners will be invoked again.
		//  making transient can only be done on non-nested objects (i.e., the root level object in the transaction).
		//  Therefore we navigate to the root object that is not nested.
		BusObject rootObject = getRootObject();
		if (rootObject.isPersistent())
		{
			rootObject.makeTransient();
		}
		
		if (dbObject==null)
		{
			// Objects that are not updated will still have the onAfterCommit invoked.
			//  Hence we need to check for this.
			return;
		}
		
		if (!dbObject.isPersistent()) return;
		
		// Map all DB properties back into the object (except for the empty and NULL values)
		BusObjectHelper.mapDBClassAttributes(dbObject, domainObject, true);

		if (revisionObjectEventListener!=null) revisionObjectEventListener.onAfterCommit();

// Perhaps this code can be activated?		
//		domainObject.onAfterCommit();
	}
	
	private BusObject getRootObject()
	{
		BusObject object = domainObject;
		while (object.sys_isInnerObject())
		{
			object = object.sys_getOuterObject();
		}
		return object;
	}
	
	void onAfterAbort()
	{
		if (revisionObjectEventListener!=null) revisionObjectEventListener.onAfterAbort();
	}
	
	/**
	 * Find the masterdata object in the database.
	 * This method should only be invoked from getDBObject().
	 */
	void findMasterDataObject()
	{
		// Read the values of the MasterData object, 
		//  Query the database to find the right db object
		//  and set the reference to the domain parent
		
		// First try to read the db object by using the values in the specified columns of the domain object
		getObjectByValueColumns();
		
		// If the object is null, but it can be inserted, then we can also create a new one.
		if (dbObject==null && metadata.getAnnotation(MasterData.class).isUpsertable())
		{
			createNewMasterDataObject();
		}
		
		if (dbObject!=null)
		{
			// Now map the values from the db object into the domain object. In case only few values
			//  have been given by domain object, then now all values will be filled.
	    	BusObjectHelper.mapDBClassAttributes(dbObject, domainObject, true);
		}

		domainObjectWrapper.getPersistenceMapping().setDatabaseObject(dbObject);
	}
	
	private void getObjectByValueColumns()
	{
		// TODO: we could think of allowing multiple different column names to be used to retrieve the
		//  masterdata object. E.g., not only the valueColumns,  but e.g. also the key columns, and
		//  possibly we can even think of different sets of value columns: e.g., either search
		//  on social security number, or on the combination of name + birth date + place, etc.etc.
		String[] valueColumns = domainObject.getClass().getAnnotation(MasterData.class).valueColumns();
		boolean hasValueColumns = valueColumns.length>0;

		// If there are no value columns, then we will use the primary key attributes.
		if (!hasValueColumns)
		{
			valueColumns = metadata.getDomainClassInfo().getUIDElements();
		}
		
		// Create a query that will read the masterdata record
		Query query = new Query(metadata.getDbClass());
		
		// Query the object based on the value columns.
		for (int i=0; i<valueColumns.length; i++)
		{
			String columnName = valueColumns[i];
			Column column = query.getTable(metadata.getDbClass()).getColumn(columnName);
			
			String columnValue = domainObject.getStringProperty(valueColumns[i]);
			
			// Do a specific check on the columnValue to find out if we need to fire the query at all
			if (columnValue==null // if the value is not given from in the soap request ...
				&& !hasValueColumns // ... and we are searching on the primary key fields ...
				&& metadata.getAnnotation(MasterData.class).isUpsertable()) // ... and this type of masterdata is upsertable ...
			{
				// This means we are searching by primary key, it is not filled, hence we cannot query the object
				//  from the database. But it is also upsertable, so probably this is an database-generated key.
				//  Then let's not even bother to fire the query and return straightaway.
				return;
			}
			query.where(column, Operator.Equals, columnValue);
		}
		
		dbObject = query.getObject();
		
	}
	
	private void createNewMasterDataObject()
	{
		// First check in current WS-AppServer transaction whether the 
		//  masterdata db object is already created (can happen when inserting the same masterdata object twice in same soap transaction)
		//  TODO: this algorithm originates from before we have the DomainObjectPersistence class. Probably better to use that one only.
		String[] keys = new String[2];
		keys[0] = "Name";
		keys[1] = domainObject.getStringProperty(keys[0]);
		Class<? extends BusObject> dbClass = metadata.getDbClass();
		dbObject = BusObjectHelper.getObjectInCurrentTransaction(dbClass, (Object[])keys);
		if (dbObject!=null)
		{
			return;
		}
		
		// Not yet available in current transaction, create a new instance.
		try
		{
			dbObject = metadata.getDbClass().newInstance();
			BusObjectHelper.mapDBClassAttributes(dbObject, domainObject, false);
			dbObject.insert();
		}
		catch (InstantiationException e)
		{
			throw new RuntimeException("Cannot instantiate masterdata class", e);
		}
		catch (IllegalAccessException e)
		{
			throw new RuntimeException("Cannot instantiate masterdata class", e);
		}
	}

	RevisionObjectEventListener getRevisionObjectEventListener()
	{
		return revisionObjectEventListener;
	}

}

